为什么要使用Webpack

本文整理自GitChat居玉皓的达人课《Webpack 前端工程化入门》

曾经的 Web 开发的做法和缺陷:

  • 在 HTML 中插入一个 script 标签,直接在里面书写代码;问题:
  • 全局作用域的污染。由于在每个 script 标签下顶层作用域即全局作用域,直接进行变量和函数声明会造成全局命名空间污染。假如一个页面有多个 script 标签,它们之间很有可能发生命名冲突。
  • 代码重用性差。在一个多页面应用的场景下,经常会有一些逻辑是这些页面之间共有的,此时我们不得不将这些代码复制粘贴到各个页面中。而当此处逻辑改动的时候我们也需要去更新所有页面的代码,造成很多额外的成本。

后来的解决办法

后来逐渐有一些针对这些问题的解决办法:

  • 首先,可以将 HTML 中内联的 JavaScript 提取出来成为单独的 JavaScript 文件。比如说一些页面公有的逻辑可以放在类似 common.js 中来被各个页面引用,这可以解决各个页面之间重用的问题。
  • 至于全局作用域污染的问题,则可以使用立即执行函数表达式将它包起来( IIFE ),只把接口暴露到全局上。
1
2
3
4
(function() {
// 通过立即执行函数表达式将作用域隔离
var foo = "bar";
})();

看上去好像问题已经得到了解决,然而随着页面逻辑的复杂度增加开发者又面临了新的问题:

  • 页面 JavaScript 文件的引用顺序由于 HTML 页面引用和处理 JavaScript 文件只能是顺序的(不考虑 async 等),因此页面的 JavaScript 之间依赖关系也必须是顺序的。而我们知道一个大型工程内部的模块依赖关系通常是树状的(比如 index.js 依赖 a、b、c 三个模块,而 a、b、c 又有各自的依赖),简单的顺序依赖关系无法满足需求。例如在 jQuery 最流行的时期,jQuery 本身以及其相关的插件之间有着各种各样的依赖关系,有些库可能自身包含 jQuery,不同的插件可能需要不同的 jQuery 版本,这些问题都不是简单的顺序依赖关系可以解决的。
  • 页面引用的 JavaScript 文件的长度与数量如何权衡随着页面逻辑的增加,工程中的 JavaScript 文件越来越长,也越来越难以维护。一个页面的单个 JavaScript 文件可能有数千行甚至上万行。而如果按照功能来把页面逻辑切割成一个个小的 JavaScript 文件,则最终会走到另一个极端——页面请求过多。我们知道每个 HTTP 请求都是需要连接时间的,对于小模块而言每一个都要单独建立连接总归得不偿失,必然会导致页面渲染速度的下降。

走向正轨的第一步——模块化

  • 模块标准
    CommonJS 以及 AMD 的出现,为前端定义了模块的标准。
  • 实现模块化标准的库也有了实现这些模块化的库,比如 RequireJS 以及 Browserify。可以让开发者将自己工程中的代码按模块进行划分,模块之间也不再仅仅是简单的顺序依赖关系。

  • 对于开发者而言开发体验更加友好,因为开发中每次需要关注的仅仅是单个模块,而不是堆放在一起的上千行 JavaScript 文件;

  • 而对于客户端来说则只用接受单一的打包产物,解决了文件数量过多导致 HTTP 请求耗时长的问题。

作用:

  • 作用域封装,避免全局变量污染
  • 提高代码复用性
  • 解除耦合
  • 按需加载

JavaScript 模块发展简史

最原始的方式: script 标签

1
2
3
<script src="//mycdn.com/moduleA.js"></script>
<script src="//mycdn.com/moduleB.js"></script>
<script src="//mycdn.com/main.js"></script>

缺陷: 这种方法的缺陷是所有这些脚本都是共用全局的作用域。在任何一个 JavaScript 文件中进行顶层作用域的变量或函数声明,都会暴露在全局中,使得其它脚本也相应获取。当应用的规模和复杂度上升,这些脚本之间很容易发生命名冲突,从而导致不可预知的问题。

立即执行函数表达式(IIFE)

IIFE 的实现原理是把代码包裹在一个函数中,并且在声明这个函数之后立即执行它,这样相当于为代码单独创建了一个作用域。比如在下面的代码中我们创建了一个立即执行函数表达式,变量的声明处于它自己的函数作用域内,与其它的模块作用域隔离开:

1
2
3
4
5
(function() {
// foobar 并不会暴露在全局作用域
var foobar = "Hello IIFE!";
console.log(foobar);
})();

如果通过这种方法封装的模块需要与别的模块发生交互,则可以将特定的对象绑定在全局来允许其它模块通过全局对象获取,比如下面的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// calculator.js
(function() {
// 将 calculator 绑定在全局对象上,使其它模块调用
window.calculator = {
add: function() {
//...
},
sub: function() {
//...
}
//...
};
})();

// app.js
calculator.add(1, 2);

立即执行函数表达式的缺点在于,如果模块之间有着依赖关系,必须将它们按照特定的顺序引入到页面中。比如上面的 calculator,必须使定义 calculator 的 calculator.js 先执行,再执行调用的模块。由于这种依赖关系是隐式的,当所有这些模块都互相依赖时,文件的引入顺序将变得难以维护。

AMD

AMD 是 Asynchronous Module Definition(异步模块定义)的缩写。下面的代码使用 AMD 规范定义了一个模块:

1
2
3
4
5
6
// 定义一个求和的模块
define("getSum", ["math"], function(math) {
return function(a, b) {
console.log("sum: " + math.sum(a, b));
};
});

在 AMD 中定义模块是使用 define 函数,它可以接受三个参数。

  • 第一个参数是当前模块的 ID,相当于给这个模块起一个名字;
  • 第二个参数是当前模块的依赖,比如上面我们定义的 getSum模块需要 math 模块的依赖;
  • 第三个参数可以是函数或者对象。如果是函数,可以利用函数的返回值将定义的模块接口导出;如果是对象,则代表它为当前模块的导出值。

通过这种形式定义模块的好处在于:

  • 显式 地表达出了每个模块所依赖的其它模块。
  • 并且模块定义也不再绑定到全局对象上,不必担心其在别的地方被篡改。

Browserify 与 CommonJS

Browserify 的出现带来了浏览器环境模块的变革。它是一个运行在 Node 环境下的模块打包工具,可以把模块按照 Node.js 的模块规则合并为浏览器支持的形式,这使得浏览器端的框架类库也可以按照 CommonJS 的形式编写。在 CommonJS 中每个文件是一个模块,并且拥有属于自己的作用域和上下文。模块的依赖通过 require 函数来引入。

1
const math = require("./math");

如果想把模块的接口暴露给外部,则要通过 exports 将其导出,如:

1
2
3
exports.getSum = function(a, b) {
return a + b;
};

AMD 和 CommonJS 具有同样的特性——模块的依赖必须显式引入,这样就解决了之前维护复杂模块引入时的顺序问题。

ES6 Module

ES6 Module 是目前比较推荐开发者使用的模块标准。之所以在过去我们有各种不同的模块化标准是因为 JavaScript 这门语言本身不具备模块化的特性,而现在 ES6 中已经具备了。ES6 Module 的模块语法和 CommonJS 很像,它通过 importexport 来进行模块的导入和导出。

1
2
3
4
5
import math from "./math";

export function sum(a, b) {
return a + b;
}

在 ES6 Module 中也是每个文件作为一个模块。和 CommonJS 不同的是,ES6 Module 的模块的依赖是静态的,或者说是在编译时确定的,而不是运行时确定的。

举个例子,我们可以在 CommonJS 中的 if 语句中 require 模块,根据代码运行时 if 的判断条件决定是否要引入该模块。

1
2
3
4
// 根据运行时条件确定是否引入
if (Date.now() > new Date("2019-01-01")) {
require("./my_module");
}

而在 ES6 Module 中则不允许这样做,import 必须在代码的顶层作用域,这意味着你不能把它放在 if 等代码块中。ES6 Module 这样规定的原因在于可以使编译器在编译阶段就可以获取到整个依赖树,从而进行代码静态分析层面的优化,比如检测出哪些模块是从来没有被使用过的,然后从打包结果中优化掉等等。

动态加载模块

  • CommonJS 可以直接使用require实现:
1
2
3
4
5
if (condition) {
require("moduleA");
} else {
require("moduleB");
}
  • 在 ES6 Module 中,由于上面我们提到的 import 是在编译时被处理而非运行时,因此无法实现动态加载的特性。
1
2
3
4
5
6
7
8
9
// 报错
if(condition) {
require('moduleA');
}

// 报错
var foo = 'foo';
var bar = 'bar';
import foobar from (foo + bar);

为了解决这个问题,tc39 提出了一个 import() 函数提案。它可以接受一个参数,指定所加载的模块,并且返回一个 Promise 对象。

1
2
3
4
5
6
7
8
9
var foo = "foo";
var bar = "bar";
import(foo + bar)
.then(module => {
console.log("foobar loaded:", module);
})
.catch(err => {
console.log(err);
});

模块打包原理简述–Webpack

Webpack 以及其它的一些打包工具最基本的功能就是按照我们定义好的依赖树将模块合并成单一的文件,让浏览器能够按照预想的依赖顺序去执行。这个过程我们通常将它叫做模块打包。
Webpack 打包 ES6 Module 的例子:

1
2
mkdir webpack-bundle && cd webpack-bundle
touch app.js && touch module.js && touch webpack.config.js
1
2
3
4
// app.js
import moduleLog from "./module.js";
document.write("app.js loaded.");
moduleLog();
1
2
3
4
// module.js
export default function() {
document.write("module.js loaded.");
}

使用简单的配置文件:

1
2
3
4
5
6
7
8
9
10
11
// webpack.config.js
const path = require("path");

module.exports = {
entry: "./app.js",
mode: "development",
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js"
}
};

然后使用 Webpack 进行打包:

1
2
# Webpack 版本需要大于等于 2,这里使用的版本是 4.5.0
webpack

app.js 是我们的打包入口文件,dist/bundle.js是最终的打包合并结果文件。Webpack 会在打包的过程中从入口 app.js 开始查找所有依赖的模块,并最终包装和合并这些模块放在 bundle.js 中。接下来让我们分析一下打包结果 dist/bundle.js 的大体结构:

1
2
3
4
5
6
7
8
9
10
11
(function(modules) {
var installedModules = {};

function __webpack_require__(moduleId) {
/* code */
}
// ...
return __webpack_require__(0); // entry file
})([
/* modules array */
]);

这段代码总体上来看是一个立即执行函数表达式,它只有一个参数 modules,即 Webpack 从入口文件开始检索到的所有依赖模块。每一个依赖模块会被 Webpack 进行一次包装,放到 modules array 的数组中等待代码运行时调用。上面立即执行的匿名函数体内分为几个部分。首先定义了一个 installedModules 对象用来放置已经加载过的模块。接着定义了 __webpack_require__,这个函数是 Webpack 模块加载的核心,可以认为它是浏览器环境下的 require。在函数体最后使用 __webpack_require__加载了工程的入口模块,在浏览器中执行时即会从入口开始去逐步执行后面的模块。让我们看一下 __webpack_require__ 的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function __webpack_require__(moduleId) {
// Check if module is in cache
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = (installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
});
// Execute the module function
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}

通过上面代码可以看出 __webpack_require__ 主要做了几个事情:

  1. 检查该模块是否已经加载过,如果是则直接返回已加载过的
  2. 添加 moduleId 等属性,并把该模块放进 installedModules
  3. 将模块 this 指到它的 module.exports,并执行已经包装好的模块逻辑
  4. 返回 modules.exports
    在 Webpack 包装模块代码的时候,会把 webpack_require 作为参数传进去。在实际的模块代码中,导入其它模块的语句都会被替换为这种浏览器可以执行的形式,通过这种方式使模块间有了互相调用的能力。
    纵观整个 bundle.js,Webpack 主要在打包中处理了下面这些问题:
  5. 从入口文件开始分析整个应用的依赖树
  6. 将每个依赖模块包装起来,并放到一个数组中等待调用
  7. 实现模块加载的方法,并提供到模块执行的环境中,使得模块间可以互相调用
  8. 将执行入口文件的逻辑放在一个立即执行函数表达式中当浏览器执行这个 bundle.js 时,首先会执行入口文件的逻辑,接着会利用 Webpack 提供好的模块以及模块的加载方法来根据依赖关系一步步执行整个应用。以上就是一个简单的 Webpack 处理打包的过程。